Skip to content

fix(objectql): multi update 的 SET 载荷剥掉非 id 的 data.id (#6262) - #6433

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-6262-multi-payload-id-strip
Aug 7, 2026
Merged

baozhoutao merged 2 commits into
mainfrom
claude/issue-6262-multi-payload-id-strip

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6262

按分诊评论「Scope as queued = route A only」执行:只做 A 案(派发层剥离),零 verdict 变更,B 案(响亮拒绝)不在本次范围。

问题

update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true }) 的派发自 #5748 裁 A / PR #5919 起就是对的:算子对象不是主键,不再遮蔽派发阶梯,声明的 bulk intent 照做,调用落到 driver.updateMany。#5919 没做、#5922 也按 PD #10 明确留在范围外的,是载荷那一半。

实测复现(worktree @ origin/main,记录型 driver 驱动真实引擎,新增测试在打补丁前的失败断言原文):

AssertionError: SET payload was {"id":{"$in":["a","b"]},"title":"x"}: expected true to be false

与 issue 正文的 PROBE 逐字一致:驱动被要求把一个序列化的算子对象写进每一条命中行的主键列。五个后端会对这件事各给一个答案(#5240 / #4434 家族),而在接受它的后端上,命中行的身份不可逆地丢失。

修法

packages/objectql/src/engine.ts 的 update multi 分支载荷组装点(分支第一件事,encryptSecretFields 之前):载荷带 id 键时剥掉,并按 warn 记一条点明后果与两种正确写法的日志。

论证只有一句:走到 multi 分支本身就意味着 resolveEngineUpdateDispatch 答了 multi,即它在两个 id 来源里都没找到真值标量 id —— 所以此刻 data.id 里的任何东西(算子对象、数组、null、假值标量)都是引擎已经裁定不是主键的值。剥离是同一个问题的同一个答案多用在一层上,不是第二个答案:不是主键的东西,也就不该坐在主键列上。

必答项一:与 #5922 / #5748 的语义一致性

与 #5748(裁 A / PR #5919)一致 —— 这是它的另一半,不是它的回退。
#5748 把 data.id 送进了和 where.id 同一个标量测试,答案是「这个 data.id 不是主键」;它据此做了一件事(不再遮蔽阶梯,multi 照做)。本 PR 据同一个答案做第二件事(不再留在主键列位置)。ENGINE_UPDATE_DISPATCH_CASES 一行未动,operator object in data.id WITH multi:true 仍是 'multi',engine-update-dispatch.test.ts 用真实引擎逐条驱动的 25 例全绿(该文件 36/36)。反过来的 B 案要反转这条刚落地的 case,那才是对裁 A 的部分回退。

与 #5922 一致 —— 它留下的正是这条轴,而不是校验轴。
#5922 收口的是「声明值为标量的字段」上的算子对象,走 record-validator;id 被 SKIP_FIELDS 按设计跳过(引擎自有列),因为这一格的裁定写在派发层。若改在 record-validator 里拒收同一个调用,就是对同一个问题给出第二个答案 —— 正是 engine-update-dispatch.ts 这一族模块被抽出来防止的事(#4550 / #4434)。本 PR 落在派发已给出的答案上,record-validator.ts 一字未动,两条轴仍各管各的。

「一个问题一个答案」不被破坏的可检验形式:唯一的判定仍只有 resolveEngineUpdateDispatch 一处;剥离不重新问「这是不是 id」,而是消费分支本身携带的答案(kind === 'multi' ⇒ 无 id)。代码里没有第二个标量测试、没有 ?? 兜底、没有手抄的 if。

必答项二:B 案(响亮拒绝)将来若裁定,要动哪里(只答不做)

  1. packages/metadata-core/src/engine-update-dispatch.ts —— 判定本体。resolveEngineUpdateDispatch 需要新增一个 reject 前置:data.id 存在且非标量真值 且 options.multi 为真 ⇒ reject(今天这条落在 if (options?.multi) return { kind: 'multi' })。同文件的 ENGINE_UPDATE_DISPATCH_CASES 里 operator object in data.id WITH multi:true 与 array data.id with multi:true 两例的 expect 由 'multi' 改为 'reject',模块头 point 2 的叙述需要重写(「不再遮蔽阶梯」变成「非标量 data.id 本身即拒绝理由」)。新拒绝语句大概率需要一条独立的 message 常量,而不是复用 ENGINE_UPDATE_REJECT_MESSAGE(「既没点名一行也没声明 bulk」和「声明了 bulk 但载荷里塞了个非 id 的 id」是两种不同的作者错误,共用一句话会把诊断打回原点)。
  2. packages/objectql/src/engine.ts —— 生产者侧。update() 末尾那条 else { throw new Error(ENGINE_UPDATE_REJECT_MESSAGE) } 是 hook 改写后重问判定的地方,需要按新 message 分叉;本 PR 加的剥离块整块删除(拒绝之后没有载荷可剥)。
  3. 拒绝面的连带:所有钉在 assertEngineUpdateDispatch 上的假引擎自动跟进(这正是该模块存在的理由,不需要逐个改);但 scripts/check-engine-double-contract.mjs 的 DEBT 账本里那 133 条未钉的替身会开始与生产者分歧,需要重新测量。
  4. 消费者侧诊断:REST/flow update_record 把新拒绝映射成 4xx 而非 500 —— 属 packages/rest 的 mapDataError 与 automation 侧执行器,拒绝语义落地时才有意义。
  5. 需要新裁决的原因(不是工作量):B 是对 ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 裁 A 的部分回退,而 A 与 B 在同一个业务场景上给用户不同的东西 —— A 让「声明了 bulk intent 就照做」继续成立(作者多写了个 id 谓词,行集由 where 决定),B 认为这种调用形状本身即作者错误、必须响亮。这是产品判断,不是实现判断。

变更清单

文件 改动
packages/objectql/src/engine.ts multi 分支载荷组装点新增 id 剥离 + 论证注释(+51)
packages/objectql/src/engine-update-multi-payload-id.test.ts 新增,11 例(+227)
.changeset/engine-update-multi-payload-id-strip.md 新增,@objectstack/objectql patch

⛔ 未触碰:ENGINE_UPDATE_DISPATCH_CASES / metadata-core 全包、record-validator.ts、非 multi 路径、事务区(#6403)、自增、剥离时序区(#5591 / #6343)、summary、content/docs/releases/。

测试

新增 packages/objectql/src/engine-update-multi-payload-id.test.ts,11 例三组:

  1. PROBE 钉死:算子对象 / 数组 / null 的 data.id + multi ⇒ updateMany 载荷无 id 键,title 照常落地;调用方传入的载荷对象不被就地改写(剥离走浅拷贝,与本路径其它 strip 一致)。
  2. 无 id 与 where.id 侧不变:multi 且载荷从未带 id ⇒ 载荷与行域 AST 双双原样;where: { id: { $in: [...] } } 仍由 AST 选行,载荷不动。
  3. 假值标量与单 id 路径:{ id: 0 } / { id: '' } + multi 的判定仍是 multi(engine-delete-dispatch 的共享判定与 ObjectQL.delete 在「假值标量 id」上不一致 —— where: { id: 0 } 判定答 by-id,引擎却 reject #5747 / ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 语义,原样);单 id 路径(data.id 标量压过 multi、where.id 标量、以及 ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 的「算子 data.id 旁有标量 where.id」头号形状)全部 driver.update,载荷按原样送达 —— 主键走独立参数,载荷里的 id 是冗余而非破坏,本 PR 不动它,并按现状钉死,使将来任何扩大剥离范围的动作都必须是刻意的。

反向验证(方向:红,如预测)

肢 A = 去掉剥离。这次的测量顺序天然就是这个实验:测试文件先在未打补丁的 origin/main 上跑,engine.ts 一字未改 —— 5 例红,且失败信息直接印出问题载荷:

Test Files  1 failed (1)
     Tests  5 failed | 6 passed (11)

FAIL  the PROBE shape: operator-object data.id + multi:true reaches updateMany with NO id in the payload
AssertionError: SET payload was {"id":{"$in":["a","b"]},"title":"x"}: expected true to be false

打补丁后同一文件 11/11 绿。注意 does not mutate the payload object the CALLER handed in 一例在补丁前就是绿的(引擎当时根本不剥,自然不会改到调用方对象)—— 它是对修法的护栏,不是复现用例,如实记在此处而非充作反向证据。

同一次运行还证明了「只有这 5 例动了」:补丁前整包 5 failed | 2327 passed (2332),补丁后 2332 passed (2332),总数一致。

命令与实测输出

pnpm --filter @objectstack/objectql test         → Test Files 141 passed (141) / Tests 2332 passed (2332)
pnpm --filter @objectstack/objectql typecheck    → tsc --noEmit,无输出
pnpm check:engine-double-contract                → OK — 80 pinned, 133 in the DEBT ledger, 4 exempt
node scripts/check-nul-bytes.mjs                 → OK (scanned 6070 tracked text file(s))
npx eslint (两个改动文件)                          → exit 0

消费半径(multi 分支的下游调用者)另跑:

pnpm --filter @objectstack/rest test                 → Test Files 64 passed (64) / Tests 881 passed (881)
pnpm --filter @objectstack/service-automation test   → Test Files 68 passed (68) / Tests 806 passed (806)

合并 origin/main(至 7618ee814)后按 AGENTS.md §10 重跑:packages/spec 在对侧动过,故 pnpm --filter @objectstack/spec build && check:generated → All 10 generated artifacts are up to date;objectql 全量 test + typecheck 复跑仍全绿。


Generated by Claude Code

claude added 2 commits August 7, 2026 18:23
…payload (#6262)

`update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })` has
dispatched correctly since #5748 / PR #5919 — an operator object is not a
primary key, so it stops shadowing the ladder and the declared bulk intent is
honoured (`driver.updateMany`). What that fix did not do is clean the PAYLOAD.
Measured on origin/main with a recording driver over the real engine:

    updateMany({ object: 'probe_task' }, { id: { $in: ['a','b'] }, title: 'x' })

i.e. the driver is asked to write a serialized operator object into the
primary-key column of every matched row. Five backends would each answer that
differently (the #5240 / #4434 family), and on the ones that accept it the
matched rows lose their identity irreversibly.

Reaching the multi branch AT ALL means `resolveEngineUpdateDispatch` returned
`multi`, i.e. it found no scalar truthy id in EITHER source — so whatever sits
in `data.id` there is a value the engine has already RULED is not a primary
key. The strip is that same answer applied one layer on, not a second opinion:
a value that is not the primary key does not get to sit in the primary-key
column either.

- Zero verdict change: `ENGINE_UPDATE_DISPATCH_CASES` is untouched and
  `operator object in data.id WITH multi:true` still expects 'multi'.
  Rejecting the call instead (#6262 route B) would reverse that just-landed
  case — a partial rollback of #5748's ruling A, which needs a fresh decision.
- No reachable legitimate write is lost: a truthy scalar `data.id` outranks
  both `where` and `multi` and never reaches this branch, and N rows cannot
  share one primary key anyway.
- The by-id path is unchanged and pinned as-is: `driver.update` takes the
  primary key in its own argument, so the key in the payload is redundant
  rather than damaging.
- Falsy scalars keep the #5747 / #5748 dispatch semantics (still 'multi') and
  are stripped on the same argument — stripping operator objects while leaving
  `{ id: 0 }` in would be a second rule about one fact.

The drop logs at warn, naming the consequence and both correct spellings.
Deliberately not routed through `onFieldsDropped`: `DroppedFieldsEvent.reason`
is a closed enum over the two read-only strips (#3407 / #3042), and widening
that vocabulary is a `packages/spec` change with its own consumers.

Fixes #6262

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 6:30pm

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

范围外发现(PD #10,均未在本 PR 内修改)


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 18:38
@baozhoutao
baozhoutao enabled auto-merge August 7, 2026 18:38
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 2a0d65e Aug 7, 2026
25 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-6262-multi-payload-id-strip branch August 7, 2026 19:01
lemonhub-io pushed a commit to OpenFork-org/objectstack that referenced this pull request Aug 8, 2026
…e payload (objectstack-ai#6435) (objectstack-ai#6475)

The by-id half of objectstack-ai#6262 / PR objectstack-ai#6433. When `data.id` is a non-scalar (operator
object, array, `null`) or a falsy scalar and `options.where.id` is a truthy
scalar, `resolveEngineUpdateDispatch` correctly rules the payload value is not
a primary key and binds `where.id` instead (objectstack-ai#5748 / PR objectstack-ai#5919). The dispatch was
right; the PAYLOAD was never cleaned, so `driver.update(object, 'rec_1', data)`
carried the ruled-not-an-id value into the SET clause and driver-sql wrote
`UPDATE task SET id = '{"$in":["a","b"]}' WHERE id = 'rec_1'` — the row's
identity overwritten irreversibly.

Route A only: strip that payload `id`, on a copy, leaving a truthy scalar
`data.id` exactly as it was (there the payload key IS the bound id — a
same-value no-op). Zero dispatch verdicts change; membership is asked by
calling the producer's own `resolveEngineUpdateDispatch`, never by re-deriving
the unexported scalar test.


Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…ed id strip (objectstack-ai#6437) (objectstack-ai#7125)

`DroppedFieldsEvent.reason` was a closed enum over the two READ-ONLY strips, so
the primary-key strip added by objectstack-ai#6262 / PR objectstack-ai#6433 (multi) and objectstack-ai#6435 (by-id) was
invisible to `onFieldsDropped` and `strictReadonlyWrites`. Adds `primary_key`
and routes both strip sites through `reportDroppedFields`.

Because `strictReadonlyWrites` coverage is DERIVED from the reported set
(measured: `strictDrops.push` applies no reason-class filter), reporting the new
reason also adds a refusal — deliberate, documented, pinned both ways. The
refusal message is now composed from `drops` so a `primary_key` rejection never
claims the field was read-only; the read-only-only message stays byte-identical.


Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
… commits that decided them (stage 4) (objectstack-ai#20548)

Part of objectstack-ai#20234
Clause-②: no

## What changed

This is stage 4 of the staged sweep: the `data/` remainder. It covers
the six `packages/spec/src/data/` files stage 3 (PR objectstack-ai#20533, landed
`03b19d9cfd`) left out because an open PR held them, and nothing else.
They are `object.zod.ts`, `filter-logic-conformance.ts`,
`object.form.ts`, `data-engine.zod.ts`, `data-engine.test.ts` and
`hook.form.ts`. Later stages cover the other areas, so this PR says
`Part of`.

The census below measured all six. Three of them carry comment or
docblock sites that cite a tracker number answering 404.
`data-engine.zod.ts`, `data-engine.test.ts` and `hook.form.ts` carry
none, so they are not in the diff.

Every such site has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123). That is **19 sites on 19 lines in 3 files,
covering 9 numbers**. Each rewritten line now cites the commit in
`origin/main` history that decided what the line describes, and it says
in its own words what that commit decided. Where a PR number was already
on the line (`PR objectstack-ai#13529`), it stays beside the commit as the link.

No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/`
records the decision behind any of the 9 numbers: a search for each
number, with and without `#`, finds nothing there. So every anchor is a
commit: **9 distinct shas**. Stage 3 had already read these commits and
recorded them in PR objectstack-ai#20533's body. They were not copied from there. Each
one was re-read against the current line it anchors: its own message or
diff names the number it replaces, and it made the change the line
describes. `object.zod.ts` and `filter-logic-conformance.ts` moved on
`main` after stage 3 read them (PRs objectstack-ai#20521 and objectstack-ai#20523). Each site was
therefore re-read at this base, `03b19d9cfd`.

Only comments changed. Every source file keeps its line count (20 lines
out, 20 in, over 3 files), so no line citation into these files moves.
One of the 20 lines held no dead citation:
`filter-logic-conformance.ts:249`, the first half of a sentence reflowed
onto `:250`. No code token moves (see the guard below).

**No tracker number is added.** Every tracker number on an added line
was already in the hunk it replaces. `PR objectstack-ai#13529` stands on three added
lines, and on the three removed lines of the same hunks. It is the link
beside commit `9dac1ae01`, which stage 3 recorded the same way.

No reference page under `content/docs/references/` moved: none of the
rewritten docblocks projects into one (`check:docs` at the head: `226
generated files in sync`). The PR adds one `patch` changeset for
`@objectstack/spec` (see Changeset below).

## Census: the six files, before and after

**Instrument.** This is the instrument of stages 1 to 3. It sends REST
`GET /repos/objectstack-ai/objectstack/issues/N` without following
redirects, for every distinct number cited in `packages/spec/src/data`.
The population is:
- the citation gate's own exported `CITATION_RE` and
`NON_CITATION_HEADS`, kept when the qualifier is none, `objectstack`,
`objectstack-ai/objectstack`, `framework`, `pre-` or `post-`;
- widened case-insensitively to `Pre-`, `POST-` and `Framework`, as in
stage 3;
- N of 100 or more, excluding `summon` heads.

Each site is classified by the TypeScript parser as a line comment, a
docblock, a block comment or a string.

Two cross-checks close the population. First, a raw `#N` count in each
of the six files equals the census rows plus the cross-repo rows in five
files. In the other two it is one higher, and the extra is a second
number after a slash inside a string (`objectstack-ai#5322/objectstack-ai#5134` in a `note`,
`objectstack-ai#6262/objectstack-ai#6433` in a test title). Both answer 200. Second, no spelled
citation (`issue N`, `PR N`, `card N`) occurs in any of the six.

**Controls.** The lit controls were `objectstack-ai#16862`, `objectstack-ai#16847` and `objectstack-ai#17698`. The
dead controls were `objectstack-ai#16714`, `objectstack-ai#16715` and `objectstack-ai#16697`. They were probed at
the start, after every 100 numbers and at the end: 24 of 24 lit (200)
and 24 of 24 dead (404) over 8 checkpoints in the base run, and 21 of 21
lit and 21 of 21 dead over 7 checkpoints in the head run.

| reading | tree | numbers probed | 200 | 404 | 301 or other | dead
sites, all of `data/` | dead sites, the six files | lines | files |
numbers |
|---|---|---|---|---|---|---|---|---|---|---|
| before | base `03b19d9cfd`, probed 2026-09-29T01:11:59Z to 01:15:49Z |
601 | 572 | 29 | 0 | **77** | 19 | 19 | 3 | 9 |
| after | head `53c9070dfd`, probed 2026-09-29T01:25:55Z to 01:29:35Z |
597 | 572 | 25 | 0 | **58** | 0 | 0 | 0 | 0 |

The head probe found no number newly dead since the base probe: the same
572 numbers answer 200. The base reading of 77 equals stage 3's after
reading at `96fd49caa2`.

**Per file.** Cited sites here are every in-repo citation the population
reads, live or dead.

| file | cited sites (base) | dead sites before | by class | dead sites
after |
|---|---|---|---|---|
| `object.zod.ts` | 120 | 15 | 8 docblock, 7 line comment | 0 |
| `filter-logic-conformance.ts` | 97 | 3 | 2 docblock, 1 line comment |
0 |
| `object.form.ts` | 31 | 1 | 1 line comment | 0 |
| `data-engine.zod.ts` | 48 | 0 | | 0 |
| `data-engine.test.ts` | 29 | 0 | | 0 |
| `hook.form.ts` | 0 | 0 | | 0 |

None of the 19 sites is a string, so this stage leaves no string token
behind.

## Per-number table

| number | sites / lines | anchor: what it decided |
|---|---|---|
| `objectstack-ai#8772` | 4 / 4, `object.zod.ts:2718`, `:2731`, `:2744`, `:2910` |
`75b7c240a`: Direction 2 of the 2026-08-16 maintainer ruling.
`ObjectSchema.create()` forces `required: true` on a `master_detail`
reference under `controlled_by_parent` and refuses an explicit
`required: false`. Raw parse stays tolerant, and runtime tolerance is
the ruling's other half. Its changeset records the measurement that only
the security gate closed that shape while the declaration surface
accepted it (`:2731`). ADR-0055 stays cited beside it. It is the same
anchor stage 3 gave `object.test.ts` |
| `objectstack-ai#10165` | 2 / 2, `object.zod.ts:818`, `:1036` | `801296050`:
`ttl.onlyWhen` with the canonical null predicate (maintainer ruling
2026-08-20, option A). One shared `onlyWhen` union, and both of
`retention.onlyWhen`'s conflicts mirrored. Its diff wrote both
`[objectstack-ai#10165]` blocks |
| `objectstack-ai#10347` | 3 / 3, `object.zod.ts:1006`, `:1042`, `:1049` |
`530c1df65`: the Archiver honours a declared `ttl`. It selects by the
ttl cutoff on `ttl.field` when `ttl` is declared, and by `created_at` /
`archive.after` otherwise (maintainer ruling 2026-08-20) |
| `objectstack-ai#10527` | 1 / 1, `object.zod.ts:1005` | `5649efbf9`: refuses a
diverging retention + ttl + archive triple at parse time. Its diff wrote
this very paragraph |
| `objectstack-ai#11195` | 1 / 1, `object.zod.ts:1791` | `b37231883`:
`UserActionsConfigSchema` adopts `group` / `hideFields` / `rowColor`
(the "last three" the line names) |
| `objectstack-ai#11408` | 1 / 1, `object.zod.ts:2189` | `f11fc61c5`: declares
`editMode` on the object document (maintainer ruling 2026-08-24, the
`objectstack-ai#10144` declare-or-rule-out family, which stays cited) |
| `objectstack-ai#13608` | 3 / 3, `object.zod.ts:2317`, `:2354`, `:2366` |
`fc9ba76a5`: `publicSharing.eligibility` is held at redemption, not only
at mint, fail-closed, with the undifferentiated `null` refusal. Its
changeset heads with objectstack-ai#13608. It is the same anchor stage 1 gave
`contracts/share-link-service.ts` |
| `objectstack-ai#13195` | 3 / 3, `filter-logic-conformance.ts:190`, `:250`, `:525` |
`9dac1ae01`, PR objectstack-ai#13529's squash commit, which stays as the link:
`$exists` means has-a-value on driver-memory's live mingo path, its
analytics face and driver-mongodb's `translateFilter` (the "last three
key-presence exits") |
| `objectstack-ai#12868` | 1 / 1, `object.form.ts:256` | `c459da6bc`: narrows the
per-option `default` key out of the form-view options vocabulary, which
offered a key nothing on that surface read. Commit `e808890958`, which
wrote this line, names objectstack-ai#12868 as the same offer-vs-door class |

The shas were checked at the base and again at `origin/main`
`288611e3e5`. Every one matches exactly one commit (`git rev-parse
--disambiguate`, count 1). Every one is an ancestor (`git merge-base
--is-ancestor`, exit 0 for 9 of 9). The control leg `e9584681a4` also
exits 0, and the repository is not shallow. For each commit, a grep of
its own message or diff finds the number it replaces. Seven of the nine
name it in the message. `fc9ba76a5` names it in its diff (20 lines,
including its changeset heading), and so does `c459da6bc` (8 lines,
including its changeset heading).

Wordings to check, each true of its commit:
- `object.zod.ts:2731` now reads 「closes that shape, and commit
75b7c24 records that the declaration and the enforcement disagree」.
The measurement was the card's. The commit's changeset records it: "only
the security gate closed that shape while the declaration surface
accepted it".
- `object.zod.ts:2189` reads 「Declared here by commit f11fc61's
maintainer ruling」, and `:2744` reads 「the other half of commit
75b7c24's ruling」. This is stage 3's wording for the same relation
(`object.test.ts`, 「the other half of commit 75b7c24's ruling」): the
commit that landed the ruling and quotes it.
- `object.zod.ts:1049` reads 「That is the whole of what [commit
530c1df] changed here」. Commit `52db1d1f2a` wrote the paragraph.
`530c1df65` is the change it describes.

## Mechanical guard: no code token moves

The check compares leaf tokens with comments stripped, base `03b19d9cfd`
against head `53c9070dfd`. It uses the TypeScript parser's leaf tokens
(TypeScript from the head's lockfile), so template literals are scanned
in context, and it excludes JSDoc nodes. It ran over all 3 touched `.ts`
files. It is the stage-3 instrument, unchanged.

- Real run: 13,624 base tokens (object.zod.ts 8,774, object.form.ts
3,226, filter-logic-conformance.ts 1,624), **0 files with a token
change** (exit 0).
- Comment-insertion control (`object.form.ts`): 0 files changed, as
expected (exit 0).
- Positive control (a declaration inserted into `object.zod.ts`): 1 file
reads DIFFER at token 1629 (exit 1).
- Positive control (one digit changed inside the `objectstack-ai#5322/objectstack-ai#5134` `note`
string in `filter-logic-conformance.ts`): 1 file reads DIFFER at token
889 (exit 1).

Line balance: `object.zod.ts` +15 / -15, `filter-logic-conformance.ts`
+4 / -4, `object.form.ts` +1 / -1. Line counts are equal at base and
head: 3,240, 621 and 751.

## Changeset

This change ships bytes, so a `patch` changeset for `@objectstack/spec`
is included. It says only that the provenance comments were re-anchored.
`Clause-②: no`: no export, key, value or type moves (the guard above).

Measured on the head's built package: `object.zod.ts` is
`src/**/*.zod.ts`, which `files[]` ships verbatim. The rewritten
comments also reach `dist`:
- `9dac1ae01` appears in `dist/data/index.d.ts` (the
`filter-logic-conformance.ts` docblock) and in 4 bundled `.js` files;
- `fc9ba76a5`, `f11fc61c5` and `b37231883` each appear in 22 bundled
`.js` files, and `c459da6bc` in 12;
- the positive control, the pre-existing `object.zod.ts` sentence
「Fail-CLOSED at both points」, appears in 11 bundled `.js` files.

## Gates (head `53c9070dfd`)

- **Citation judging pass, run as CI runs it:** `pnpm
check:issue-citations && node scripts/check-issue-citations.mjs` exits
0. The self-test passes 73 cases in 7 batteries. The live run judged 6
citations across 3 files: 3 resolve (`objectstack-ai#9138` twice, `objectstack-ai#11410`) and 3
resolve as a pull request (`objectstack-ai#13529`, the link).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at the head derived 79 families, and
all 79 exit 0. `--ran` reports 79 run, 0 NOT MEASURED, 0 unrun, and
exits 0. A full `turbo run build` of `./packages/*` ran first, under the
shared verify lock: 71 of 71 tasks, VERDICT command-exit 0. So no gate
met an unbuilt prerequisite.
- `pnpm --filter @objectstack/spec run check:generated`: under the lock
against that build, `All 15 generated artifacts are up to date`, VERDICT
command-exit 0.
- **Tests and typecheck:**
- `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2
src/data` under the lock: Test Files 107 passed (107), Tests 3527
passed, 1 todo (3528), VERDICT command-exit 0. It covers every test in
`data/`, among them `object.test.ts`, which reads these schemas.
- The 13 spec suites outside `src/data` that read the touched files'
source text or pin their line numbers, under the lock: Test Files 13
passed (13), Tests 544 passed (544). They are stage 3's 12
(`scripts/{file-description,root-index,skill-map-guards,strictness-ledger}.test.ts`,
`src/api/api-entry-graph.pin.test.ts`,
`src/contracts/scoped-context.test.ts`,
`src/shared/{alias-integrity,evaluated-slot-population,retired-key-migrate-sentence}.test.ts`,
`src/system/constants/platform-object-names.test.ts`,
`src/type-alias-convention.pin.test.ts`, `src/ui/dashboard.test.ts`)
plus `src/shared/union-author-message-pins.test.ts`, which pins
`data/object.zod.ts:855`.
- `pnpm --filter @objectstack/spec typecheck` under the lock exits 0,
including `check:test-typecheck` (53 files, 251 errors, 138 pinned
signatures held).
- **Lint, as a proven narrowing at the head:** `eslint
--no-inline-config --format json` over the 3 touched `.ts` files gives 3
files, 0 errors and 0 warnings. All 3 are in eslint's own population
(`isPathIgnored` is false for each). `eslint.config.mjs` never enables
type-aware linting (no `parserOptions.project`, which its own line 328
states), so a comment edit here cannot move the verdict on any untouched
file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Base.** The branch forked from `03b19d9cfd`, stage 3's landing.
`origin/main` then moved two commits (`05077d4c26`, PR objectstack-ai#20532, and
`288611e3e5`, PR objectstack-ai#20536), and neither touches `data/`. `dispatch-gates`
flagged its derivation as stale because `scripts/regen-artifacts.mjs`
had moved, so `origin/main` was merged in (`53c9070dfd`, a clean merge
with no driver-deferred path) before the gates ran. The PR's delta
against `origin/main` is exactly its 4 files. `origin/main` has since
moved two more commits: `7e36a3cd7c` (PR objectstack-ai#20531) and `ba5927f714` (PR
objectstack-ai#20460). Neither touches `data/` or anything the gate derivation reads,
and a re-derivation prints the same 79 commands. A no-driver
`merge-tree` of the head onto `ba5927f714`, from a bare shared clone,
exits 0. So there is no second merge.
- **Open PRs, re-read at 2026-09-29T02:01Z:** 9 open PRs, and none
touches any of the six files. The `data/` files open PRs touch are
objectstack-ai#20458's `analytics*` files, objectstack-ai#20504's `driver/turso.*`, and objectstack-ai#20545's
`filter-number-comparand-declared-type.*`, which is disjoint. Since the
claim, PR objectstack-ai#20460 has landed (`ba5927f714`) without touching
`filter-subtree-provenance.ts`. That file's 3 dead sites are outside
this claim's fence, so they are left for a later stage.
- **The rung.** Two anchored changes also have ADR-0087 entries in
`packages/spec/src/migrations`: `cbp-master-detail-required-forced` for
objectstack-ai#8772, and `form-view-option-default-retired` for objectstack-ai#12868. The second
entry's own header names commit `c459da6bc`. This PR takes the commit
rung, as stages 1 to 3 did. The D3 id is the more durable in-repo
record, if the ruling's first rung is later read to include those
entries.
- **What stays in `data/` after this stage: 58 dead sites.**
- **12 comment sites in files other open work still holds.**
`analytics.zod.ts`, `analytics-strictness-batchd.test.ts` and
`analytics-date-range-two-bound-window.test.ts` hold 5 (objectstack-ai#20300, PR
objectstack-ai#20458). `driver/turso.zod.ts` and `driver/turso.test.ts` hold 4
(objectstack-ai#20437, PR objectstack-ai#20504). `filter-subtree-provenance.ts` holds 3. It was held
by objectstack-ai#20367 and is now free (see above).
- **3 comment sites stage 3 left on purpose.** They are the test-read
`[objectstack-ai#6259]` marker at `api-derivation.ts:163`, the test comment at
`api-derivation.test.ts:232` that names it, and `field.zod.ts:370`,
whose `objectstack-ai#6111` is objectui's number.
- **43 string sites**, left as tokens: 41 test strings (2 of them in the
held analytics and turso test files) and the 2 exported
`AGGREGATION_CASES` note strings in `aggregation-conformance.ts`
(`:398`, `:407`, objectstack-ai#11065), which objectstack-ai#20489's claim holds.
- **Outside `data/`,** the card's other remaining items are unchanged:
the migrations and ui areas, the `liveness/**` notes, the `why` strings,
the `PROVENANCE_WAIVERS` reason, and `rest-server.zod.ts`.
- **The citation gate's reach.** It defers `packages/**/*.test.ts`. No
test file is touched here, so all 3 touched files are in its judging
population.

---
_Generated by [Claude
Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… decision in words instead of a tracker number (stage 15) (objectstack-ai#21810)

Part of objectstack-ai#20749
Clause-②: no

Stage 15 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the first name-ordered file group directly under
`packages/spec/src/data/`: the 20 test files from
`aggregate-field-type-compatibility.test.ts` to
`date-range-presets.test.ts`. They carried 94 messages and 102 tracker
ids, citing 60 records. Every one of those ids now either states what
its record decided, in words (form D), or is dropped where the title
already says it. Text only: no assertion, identifier, test count or code
comment changes.

## Census at the base (`0a3480311a`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the
copies stages 10 to 14 used. A literal counts as a test title when its
folded message is argument 0 of a `describe` / `it` / `test` call,
`.each` / `.skip` / `.only` chains included. Everything else is an
"other" string.

Both instruments read **1325 messages / 1406 ids in 282 files**, the
seat's reading at `0a3480311a` (stage 14's head).

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` (this PR: the first 20 files) | 95 | 468 / 501 | 445 / 475 |
23 / 26 |
| `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **282** | **1325 / 1406** | **1246 / 1323** | **79 / 83**
|

The group reads **94 messages / 102 ids in 20 files**, the seat's
figures, file for file:

| file (under `data/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `aggregate-field-type-compatibility.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `analytics-date-range-closed-vocabulary.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `analytics-date-range-two-bound-window.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `analytics-query-window-integer.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `analytics-strictness-batchd.test.ts` | 9 / 9 | 9 / 9 | 0 |
| `analytics.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `api-derivation.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `api-methods-batch-conformance.test.ts` | 3 / 4 | 1 / 1 | 2 / 3 |
| `authoring-key-lint.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `autonumber-format.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `autonumber-unanchored-boundary.test.ts` | 3 / 4 | 3 / 4 | 0 |
| `bulk-write-hook-conformance.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `calendar-day.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `context-tokens.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `currency-mode-family-closure.pin.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `currency-precision-iso4217.test.ts` | 7 / 7 | 7 / 7 | 0 |
| `data-engine.test.ts` | 20 / 25 | 20 / 25 | 0 |
| `datasource-credential-redaction.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `datasource.test.ts` | 10 / 10 | 10 / 10 | 0 |
| `date-range-presets.test.ts` | 2 / 3 | 2 / 3 | 0 |
| **20 files** | **94 / 102** | **92 / 99** | **2 / 3** |

- **Controls.** Lit, a title: `data/document.test.ts` reads 2 / 2 at the
head. Lit, "other" strings: the two in
`data/external-lookup-retirement.test.ts` (`:89`, `:130`) still read at
the head. Dark: the file comment at
`data/analytics-strictness-batchd.test.ts:4` (it names the strictness
batch by its number) reads 0. Planted in a scratch copy of the head
`data/calendar-day.test.ts`: an id put into a title reads 1 / 1, and an
id put into a comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals in 19
of the 20 files. In `aggregate-field-type-compatibility.test.ts` it
reads one more, a decision-batch number at `:150` that sits beside a
cited record in the same literal. The gate's pattern needs three to five
digits, so it is not counted there.
- **At the head:** 1231 messages / 1304 ids in 262 files. The 20 files
read 0 / 0 on both patterns, and no other file moved.

## How the area was chosen

`data/` has no subdirectory to split by (449 ids directly under it,
`data/driver/` 52), so its stages take name-ordered file groups near the
~100-id bound, as stage 14's report proposed. This census reads the
first group at exactly 102, the claim's figure, so the rule needed no
re-cut.

**Named for the next stages** (re-cut from the head census, 1231 / 1304;
`data/` 374 / 399 left):
- `data/` in four more stages, name-ordered:
1. `default-value-shape.test.ts` to `filter-comparand-shape.test.ts`: 20
files, 94 messages / 100 ids;
2. `filter-comparand-type.test.ts` to
`filter-view-operator-parity.test.ts`: 20 files, 95 / 99;
3. `filter.test.ts` to `object.test.ts`: 17 files, 103 / 114.
`object.test.ts` alone carries 42, so no cut lands nearer the bound;
4. `query-transport.test.ts` to `validation.test.ts` (11 files, 34 / 34)
with `data/driver/` (7 files, 48 / 52): 86 ids.
- `ui/` 416, about four stages. `api/` 201, two. `system/` 165, two. The
files directly in `src/`, 120, one.
- The three docblock needles (`ai/build-progress.test.ts:236`, `:237`,
`contracts/approval-service.test.ts:274`), one stage with their
docblocks.

## What each id became

24 literals (28 ids) now state a decision in words. 2 literals (2 ids)
get their subject back in words where the number stood in for it. 69
literals (72 ids) drop a number the title already explains. (95 literals
in 94 messages: the `sys_organization` reason string is one message over
two lines.)

Every cited record was read with its comments through REST: 55 answer
200. objectstack-ai#6345, objectstack-ai#8876, objectstack-ai#9040, objectstack-ai#10194 and objectstack-ai#17014 answer 404, and their
decisions were read from what landed: `e2798fa` (one driver vocabulary
for start and migrate), `d634e66` (the username half of the URL userinfo
grammar), `2420641` (a credential in the mongo `options` passthrough is
refused), `2306a76` (`theme` / `analytics_cube` validated at the `/meta`
write door) and `80aef80` (a one-day window for the one-day presets),
each with its CHANGELOG entry. No cross-repo record is cited in this
group.

| record(s) | literal (under `data/`) | now reads |
|:--|:--|:--|
| objectstack-ai#11152 | `aggregate-field-type-compatibility.test.ts:150` | "accepts
`sum` / `avg` / `min` / `max` over booleans — numbers on every backend,
a ruling that outranks the refused-by-default rule". The maintainer
ruled that booleans aggregate as numbers on every backend; decision
batch 80 held that ruling over batch 59's blanket refusal of unnamed
pairs. That batch number went with the id. |
| objectstack-ai#4001 (3) | `analytics-strictness-batchd.test.ts:83`, `:248`, `:306` |
"batch D, unknown keys refused — …" before "the doors the cube family is
reachable through", "alias claims are true of the surfaces they point
at" and "deliberate non-closures (re-verdicts, not omissions)". The
campaign's decision: an unknown key is refused, not stripped. |
| objectstack-ai#3878 (2) | `analytics-strictness-batchd.test.ts:270`, `:297` |
"matching the dispatcher's bespoke hint at the /analytics entry" and
"the retired-envelope tombstones still fire". The body is the bare
`AnalyticsQuery`; the `{ cube, query }` envelope was retired with
tombstones, and the entry answers 400 with a hint at `where`. |
| objectstack-ai#18612 | `analytics.test.ts:314` | "a persisted cube heals at the door
— the retired join `sql` / `relationship` are stripped (ADR-0087 D2)". |
| objectstack-ai#3391 | `api-derivation.test.ts:16` | "api-derivation — one table
resolves the effective operations from six primitives". The server is
the only adjudicator, through one derivation table. |
| objectstack-ai#3543 | `api-derivation.test.ts:286` | "vocabulary split — authors
write six primitives, the wire speaks operations". The authored enum
shrank; the wire vocabulary stayed byte-stable. |
| objectstack-ai#15873 | `api-methods-batch-conformance.test.ts:221` | A declared
reason string: "(a ruling grants `update`; both are column-clamped per
row by ADR-0092 D2)". Option (a), decision batch 64. |
| objectstack-ai#3786 | `authoring-key-lint.test.ts:37` | "lintAuthoredRecordKeys — an
unknown authoring key is reported, not swallowed", the decision its
source docblock records. |
| objectstack-ai#6555 | `autonumber-format.test.ts:23` | "DEFAULT_AUTONUMBER_FORMAT /
resolveAutonumberFormat — one declared default both sides read". Route
3: `{0000}` became the contract default, and both fallbacks went away. |
| objectstack-ai#5038 | `bulk-write-hook-conformance.test.ts:114` | "records the after
half as DELIVERED — the engine fires it once per row". |
| objectstack-ai#5574 | `bulk-write-hook-conformance.test.ts:119` | "records the
before half as DELIVERED — the engine dispatches it per row too". |
| objectstack-ai#20126 | `currency-mode-family-closure.pin.test.ts:348` |
"currency-mode family — the enumerating closure pin: `defaultCurrency`
holds only under `fixed`". |
| objectstack-ai#19992 | `currency-precision-iso4217.test.ts:163` | "the removed
`currencyConfig.precision` at rest: a stored row carrying the baked
`precision: 2` is served canonical". |
| objectstack-ai#7918 | `currency-precision-iso4217.test.ts:224` | "… where the ISO
4217 width check used to refuse it". That check was the record's option
A, later reversed. |
| objectstack-ai#3407, objectstack-ai#6437 | `data-engine.test.ts:1185` |
"DroppedFieldsEventSchema.reason — why a write dropped submitted fields,
widened past the readonly pair". |
| objectstack-ai#6262, objectstack-ai#6433, objectstack-ai#6435 | `data-engine.test.ts:1198` | "primary_key is the
value the engine reports when it strips a payload id it ruled is not an
identifier", the schema's own wording of that strip on the bulk and the
by-id paths. |
| objectstack-ai#8300 | `datasource-credential-redaction.test.ts:70` | "(the drift
guard on the one credential-key definition)". |
| objectstack-ai#8876 | `datasource-credential-redaction.test.ts:232` | "— the
username half of the same alignment". |
| objectstack-ai#8337 | `datasource-credential-redaction.test.ts:243` |
"redactUrlCredentialQueryParams — the read half: a credential query
parameter is never served back". |
| objectstack-ai#8153 | `datasource.test.ts:673` | "— unchanged by the managed-row
credentialsRef allowance". The ruling allowed `external.credentialsRef`,
and only it, on managed rows. |
| objectstack-ai#4614, objectstack-ai#8793 | `date-range-presets.test.ts:14` | "date-range preset
vocabulary — one source of truth, read by both the UI and the data
side". |

**Subject restored (2 ids):** objectstack-ai#20126 at
`currency-mode-family-closure.pin.test.ts:403` ("currency-mode closure
controls — each rule can fail, and passes what it must") and objectstack-ai#7918 at
`currency-precision-iso4217.test.ts:311` ("carries the measured anchors
— 0 digits for JPY, 2 for USD, 3 for KWD"). That literal moved from
double to single quotes, since it no longer holds an apostrophe.

**Dropped only (72 ids):** objectstack-ai#1603, objectstack-ai#2377, objectstack-ai#3026, objectstack-ai#3391, objectstack-ai#3543, objectstack-ai#3545,
objectstack-ai#3795 (9), objectstack-ai#4001 (2), objectstack-ai#4286, objectstack-ai#4346 (2), objectstack-ai#4538, objectstack-ai#4583, objectstack-ai#5586, objectstack-ai#6345,
objectstack-ai#6555, objectstack-ai#6560, objectstack-ai#7178 (5), objectstack-ai#7265, objectstack-ai#7287 (2), objectstack-ai#7802 (2), objectstack-ai#8032, objectstack-ai#8057 (2),
objectstack-ai#8153 (7), objectstack-ai#8336, objectstack-ai#8337, objectstack-ai#9040, objectstack-ai#10194, objectstack-ai#10414, objectstack-ai#13802, objectstack-ai#16041, objectstack-ai#16632,
objectstack-ai#17014, objectstack-ai#17296, objectstack-ai#17598 (2), objectstack-ai#18278, objectstack-ai#19992 (3), objectstack-ai#20011, objectstack-ai#20300 (2),
objectstack-ai#20550, objectstack-ai#20600, objectstack-ai#20808 (3), objectstack-ai#21365 (2).

- Each of these titles already states the decision it pins: for example
"empty array → deny-all (flipped semantics)" for objectstack-ai#3391, "accepts the
BARE query string — the canonical ADR-0061 D1 spelling" for objectstack-ai#7178, or
"`currencyConfig.precision` is removed: refused with the prescription,
whatever its value" for objectstack-ai#19992.
- **Small rewordings that carry no new claim:**
`analytics-strictness-batchd.test.ts:307` reads "are CLOSED now" where
it named the record; `autonumber-unanchored-boundary.test.ts:51` reads
"(ruled: mixed content is out of contract)"; `datasource.test.ts:553`
reads "(the happy path)". The circled part numbers after objectstack-ai#17598 went
with the id.
- **The two `api-methods-batch-conformance.test.ts` reason strings**
(`sys_api_key`, `sys_organization`) end "rather than hitting /batch."
now. The table is read only through `!== undefined`, so no assertion
reads their text.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. No `__snapshots__` directory exists under
`data/`, and no `.snap` file is tracked under `packages/spec`.
- **Projects:** two touched files are listed in
`packages/spec/vitest.repo-tests.json`:
`api-methods-batch-conformance.test.ts` and
`currency-mode-family-closure.pin.test.ts`. Both were run in the `repo`
project at the base and at the head, and the other 18 in `local`.
- **By substring:** every old literal, plus a window around each id (289
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 14 hits are:
- **sibling titles in other lanes:** `service-analytics`
`aggregate-nontemporal-measure-refusal.test.ts:344` and `objectql`
`engine-autonumber-default-format.test.ts:248`;
- **this card's later `data/` stage:**
`data/driver/postgres.test.ts:169`, the same "placeholders are not
resolved here" title, already in the census;
- **comments, CHANGELOG, an audit ledger and liveness evidence:** `lint`
`validate-dataset-measure-aggregates.test.ts:179`, `service-analytics`
`dataset-compiler.ts:227`, `objectql` `engine.ts:6206` and `:6272`,
`analytics.zod.ts:1002`,
`docs/audits/2026-07-unknown-key-strictness-ledger.md:728`, two
`packages/spec/CHANGELOG.md` entries and the `liveness/field.json:218`
evidence string, which quotes the `engine.ts` comment. None reads a test
title.
- **Same-text titles named in stage 14's ACCEPT** (`(objectstack-ai#15680)`,
`(objectstack-ai#5955)`, `objectstack-ai#3896 close-out`): none falls in this group.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are the three
reason-string leaves in `api-methods-batch-conformance.test.ts`.

- **Result:** 20 of 20 files SAME on all three legs, as predicted in
writing before the run.
- **Totals:** 95 changed literals, 92 titles and 3 declared. The diff's
`+` and `-` lines are exactly the 95 planned lines, and every file keeps
its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string given a
new id VIOLATION; an undeclared `expect` message changed VIOLATION; a
title re-split into a `+` chain DIFF.

**Test counts:** the 20 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 553 / 553 passed, with the same count and status sequence per
file in 20 of 20. 291 full test names change, and each equals the base
name with the planned replacements applied (0 mismatches). No full name
repeats on either side.

## `main` merged in, once

objectstack-ai#21800 (the console pin bump) landed while this branch was being
verified, and it rewrites the comment block at `:61-77` of
`api-methods-batch-conformance.test.ts`. This PR edits only string
literals in that file, more than 100 lines below the block, so
`origin/main` (`18c2ddc1ec`, which also carries objectstack-ai#21801) was merged in
with a plain merge, no rebase, and no conflict. The PR's delta against
`main` is still exactly the 20 files, +95 / -95. Every reading in this
body was re-taken on the merged head `bf16ad1190`, against `18c2ddc1ec`
as the base: the census (1325 / 1406 there, 1231 / 1304 here, unchanged
by the two commits), the text-only proof and its controls (the three
declared lines now sit at `:202`, `:230` and `:235`), the 20-file runs,
the full build, the suite, the typecheck and the gates. Re-fetched just
before this PR opened, `origin/main` was one commit further
(`75ddcd1b41`, objectstack-ai#21805, in `cloud-connection`, `metadata-core` and
`runtime`). It touches no `packages/spec` path and no file here, so it
was not merged.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
20 touched files are in it, and no `*.test.ts` at all. Of `src/`, only
the `*.zod.ts` sources ship: the controls `src/data/analytics.zod.ts`,
`src/data/data-engine.zod.ts` and `dist/data/index.js` are in it.
- In the built `dist/`, five new phrases and four old literals each read
in 0 files. The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `bf16ad1190`)

- `pnpm turbo run build` over all packages: 71 / 71 (also 71 / 71 at the
pre-merge head `89c4b300c2`).
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18360 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 20 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date after the
merge.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stages 13 and 14, and all 79 exit 0. `--ran` reconciles: 79
derived, 79 run, 0 NOT-MEASURED, 0 UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 20 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 20 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 190 changed lines.

## Acceptance notes

- **No needle in this group.** Every id was a title or a declared reason
string; no expected value of an assertion over a source docblock was
found. The three known needles are untouched.
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec` finds 156 lines citing ids this PR handled, in 79 files
of 23 packages: `objectql` 73 (29 files), `rest` 18 (7), `runtime` 7
(4), `plugin-security` 6 (5), `cli` 6 (3), `lint` 6 (4),
`service-datasource` 6 (3), `driver-sql` 4 (4), `platform-objects` 4
(2), `plugin-approvals` 3 (2), `service-automation` 3 (2),
`driver-mongodb` 3 (1), `plugin-auth` 3 (1), `service-analytics` 3 (3),
`metadata-core` 2 (1), `plugin-hono-server` 2 (1), and one each in
`client`, `triggers`, `core`, `metadata-protocol`, `qa/dogfood`, `types`
and `driver-memory`.
- **Two spec test files outside `src/`** carry same-id titles:
`packages/spec/scripts/file-description.test.ts:66` and
`packages/spec/scripts/format-type.test.ts:85`. They are outside class
(e) as ruled ("the test strings shipped under `src/`").
- **Numeric delivery fields:**
`bulk-write-hook-conformance.test.ts:115-116` and `:129-130` assert
`engineDeliveryIssue: 5038` / `5574`, numbers in the source contract
table. They are not strings, the gate's pattern cannot see them, and
they are not this card's share.
- **Code comments still carry ids** in these files and their sources,
for example the header of `analytics-strictness-batchd.test.ts` and the
`SINGLE_RECORD_WRITE_ONLY` comments in
`api-methods-batch-conformance.test.ts`. Comments are not this card's
share, and none is touched here.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data.id 是算子对象 + multi: true 时,{"$in":[...]} 作为普通列进入 updateMany 的 SET 载荷,写向主键列

2 participants